Este notebook foi desenvolvido como parte do teste técnico para a Neoway para a vaga de Cientista de Dados Sênior. Este material foi disponibilizado de forma pública sob autorização da empresa.
Para realizar a instalação dos pacotes necessários, basta executar o arquivo requirements.txt.
$ pip install -r requirements.txt
Note que o comando completo se encontra no arquivo
Product Data Science.pdf
Primeiro, vamos abrir os arquivos de dataset para analisar as informações que temos.
import pandas as pd
import numpy as np
DATA_FOLDER = 'data/'
# Note that some columns are supposed to be of type int, but they
# are casted to float due to missing values (that are not supported
# in the int type).
ind_df = pd.read_csv(f'{DATA_FOLDER}individuos_espec.csv', sep=';')
con_df = pd.read_csv(f'{DATA_FOLDER}conexoes_espec.csv', sep=';')
print(ind_df.shape)
ind_df.head()
Vamos checar a quantidade de valores vazios em cada coluna para os dois datasets.
ind_df.isna().sum()
print(con_df.shape)
con_df.head()
con_df.isna().sum()
Conforme esperado, apenas metade das conexões foram medidas.
Utilizaremos o método KNN para realizar a imputação dos valores ausentes no dataset de indivíduos. Para isso, apenas as linhas sem nenhum valor ausente serão utilizadas para treinamento do algoritmo.
A classe DataImputer realizará todo esse processo.
from customLib.DataImputer import DataImputer
di = DataImputer(ind_df)
di.impute()
imputed_ind_df = di.get_data()
imputed_ind_df.isna().sum()
imputed_ind_df.tail()
Utilizaremos a classe FeatureMaker para montar os dados de acordo com os formatos esperados para o treinamento do modelo de predição das conexões.
Caso deseje realizar a imputação dos dados, execute a linha fm = FeatureMaker(imputed_ind_df, con_df). Caso deseje utilizar os dados já processados (que estão salvos em um arquivo .csv junto com este código), execute apenas a linha fm = FeatureMaker(load_from_file=True).
from customLib.FeatureMaker import FeatureMaker
# fm = FeatureMaker(imputed_ind_df, con_df)
fm = FeatureMaker(load_from_file=True)
fm.prepare_sets()
Utilizaremos uma rede neural simples com uma única camada oculta e uma camada de saída. A função de ativação da camada oculta será a ReLU, pois o treinamento com esta função tende a ser mais rápido (uma vez que sua derivada é uma constante). Por outro lado, a função sigmoid será utilizada na camada de saída, pois seus valores variam de 0 até 1 (que são os valores que precisamos como saída).
Uma outra boa prática a ser utilizada é a quantidade de neurônios na camada oculta. Ao longo das minhas experiências, adotar uma quantidade de neurônios nessa camada igual a raiz da quantidade de entradas da camada anterior é um valor bom.
Dado mais tempo, eu faria um treinamento utilizando k-fold e faria uma busca pela quantidade ideal de neurônios na camada oculta, além de testar a adição de mais uma camada oculta (mais do que duas camadas ocultas costuma gerar um aumento computacional que não resulta em grandes melhorias preditivas).
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torch
import math
import matplotlib.pyplot as plt
class NeuralNetwork(nn.Module):
def __init__(self, input_dim):
super(NeuralNetwork, self).__init__()
self.fc1 = nn.Linear(input_dim, int(math.sqrt(input_dim)))
self.fc2 = nn.Linear(int(math.sqrt(input_dim)), 1)
def forward(self, x):
x = F.relu(self.fc1(x))
x = torch.sigmoid(self.fc2(x))
return x
input_dim = 33
model = NeuralNetwork(input_dim)
# checking for gpu
# device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# print(f'===== Using {device} =====')
# if torch.cuda.is_available():
# print(f'GPU in use: {torch.cuda.get_device_name(0)}')
# model = model.to(device)
model
Por se tratar de um problema de regressão, adotaremos o Mean Squared Error (MSE) como nossa métrica chave.
É importante citar que o Learning Rate chegou a ser testado com valores menores, sendo o valor 0.01 o que resultou em melhor convergência.
Por fim, o treinamento foi feito com apenas 500 épocas. Note que o código implementa o Early Stopping, mas que não chegou a ser alcançado com os parâmetros utilizados. Foram adotadas 500 épocas para que o treinamento não demorasse tanto. O ideal seria utilizar um valor bem alto de épocas e deixar o treinamento prosseguir até que o Early Stopping entrasse em ação.
criterion = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=0.01)
def train(model, n_epochs, optimizer, criterion, train_loader, valid_loader):
model.double()
# initialize tracker for minimum validation loss
valid_loss_min = np.Inf
# initialize losses tracking variables
train_losses = []
valid_losses = []
# early stopping parameter
stop_after = 3
trials = 0
for epoch in range(1, n_epochs+1):
# initialize variables to monitor training and validation loss
train_loss = 0.0
train_counter = 0
valid_loss = 0.0
valid_counter = 0
###################
# train the model #
###################
model.train()
for features_batch, targets_batch in train_loader:
# features_batch = features_batch.to(device)
# features_batch = targets_batch.to(device)
outputs_batch = torch.squeeze(model(features_batch))
optimizer.zero_grad()
# calculate the batch loss
loss = criterion(outputs_batch, targets_batch)
# backward pass: compute gradient of the loss with respect to model parameters
loss.backward()
# perform a single optimization step (parameter update)
optimizer.step()
# update training loss
train_loss += loss
train_counter += 1
train_loss /= train_counter
train_losses.append(train_loss)
######################
# validate the model #
######################
model.eval()
for features_batch, targets_batch in valid_loader:
# features_batch = features_batch.to(device)
# features_batch = targets_batch.to(device)
outputs_batch = torch.squeeze(model(features_batch))
# calculate the batch loss
loss = criterion(outputs_batch, targets_batch)
valid_loss += loss
valid_counter += 1
valid_loss /= valid_counter
valid_losses.append(valid_loss)
## save the model if validation loss has decreased
if valid_loss < valid_loss_min:
# print training/validation statistics
print('Epoch: {} \tTraining Loss: {:.6f} \tValidation Loss: {:.6f}\tSaving model...............'.format(
epoch,
train_loss,
valid_loss
), end='\r')
valid_loss_min = valid_loss
torch.save(model.state_dict(), 'best_model.pickle')
trials = 0
else:
trials += 1
# print training/validation statistics
print('Epoch: {} \tTraining Loss: {:.6f} \tValidation Loss: {:.6f} \tEarly stopping status: {}/{}'.format(
epoch,
train_loss,
valid_loss,
trials, stop_after
), end='\r')
valid_loss_min = valid_loss
torch.save(model.state_dict(), 'best_model.pickle')
# checking for early stopping
if trials >= stop_after:
print(f'Early stopping after {trials} attempts without improvement on the validation set!')
return train_losses, valid_losses
train_losses, valid_losses = train(model, 500, optimizer, criterion, fm.get_loader('train'), fm.get_loader('valid'))
# plot losses
plt.figure(figsize=(20, 10))
plt.plot(list(range(1, len(train_losses)+1)), train_losses, label='Training loss')
plt.plot(list(range(1, len(valid_losses)+1)), valid_losses, label='Valid loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.title('Training and validation losses')
plt.legend()
plt.show()
# Load best model
model.load_state_dict(torch.load('best_model.pickle'))
model.eval()
model.double()
def test(model, criterion, test_loader):
test_loss = 0
test_counter = 0
for features_batch, targets_batch in test_loader:
outputs_batch = torch.squeeze(model(features_batch))
# calculate the batch loss
loss = criterion(outputs_batch, targets_batch)
test_loss += loss
test_counter += 1
test_loss /= test_counter
return test_loss
test_loss = test(model, criterion, fm.get_loader('test'))
print(f'Loss média no conjunto de testes: {test_loss}')
predict_loader = fm.get_loader('predict')
from tqdm import tqdm
def predict(model, predict_loader):
model.double()
model.eval()
predictions = []
for features_batch, _ in tqdm(predict_loader):
outputs_batch = torch.squeeze(model(features_batch))
for predicted in outputs_batch:
predictions.append(predicted.item())
return predictions
predictions = predict(model, predict_loader)
features, targets = fm.fill_nan_values(predictions)
# cols = fm.get_columns().copy()
# cols.append('prob_V1_V2')
cols = ['V1_idade', 'V1_qt_filhos', 'V1_estuda', 'V1_trabalha', 'V1_pratica_esportes', 'V1_IMC', 'V1_estado_civil-casado', 'V1_estado_civil-divorciado', 'V1_estado_civil-solteiro', 'V1_estado_civil-viuvo', 'V1_transporte_mais_utilizado-particular', 'V1_transporte_mais_utilizado-publico', 'V1_transporte_mais_utilizado-taxi', 'V2_idade', 'V2_qt_filhos', 'V2_estuda', 'V2_trabalha', 'V2_pratica_esportes', 'V2_IMC', 'V2_estado_civil-casado', 'V2_estado_civil-divorciado', 'V2_estado_civil-solteiro', 'V2_estado_civil-viuvo', 'V2_transporte_mais_utilizado-particular', 'V2_transporte_mais_utilizado-publico', 'V2_transporte_mais_utilizado-taxi', 'grau-amigos', 'grau-familia', 'grau-trabalho', 'proximidade-mora_junto', 'proximidade-visita_casual', 'proximidade-visita_frequente', 'proximidade-visita_rara', 'prob_V1_V2']
norm_params = fm.get_norm_params()
from tqdm import tqdm
data = features.tolist()
# add probabilities to the list
for idx in tqdm(range(len(data))):
data[idx].append(targets[idx])
full_df = pd.DataFrame(data, columns=cols)
pd.set_option('display.max_columns', None)
full_df.head()
Nosso objetivo agora é obter os dados nos formatos e intervalos originais.
estado_civil = ['casado', 'divorciado', 'solteiro', 'viuvo']
transporte_mais_utilizado = ['particular', 'publico', 'taxi']
grau = ['amigos', 'familia', 'trabalho']
proximidade = ['mora_junto', 'visita_casual', 'visita_frequente', 'visita_rara']
def get_categorical(elements_list, categorical_values):
values = []
for element in elements_list:
values.append(categorical_values[element.index(1.0)])
return values
# estado civil
print('Processing "estado_civil"...')
cols = ['V1_estado_civil-casado', 'V1_estado_civil-divorciado', 'V1_estado_civil-solteiro', 'V1_estado_civil-viuvo']
temp_data = full_df[cols].values.tolist()
full_df.drop(columns=cols, inplace=True)
col_data = get_categorical(temp_data, estado_civil)
full_df['V1_estado_civil'] = col_data
cols = ['V2_estado_civil-casado', 'V2_estado_civil-divorciado', 'V2_estado_civil-solteiro', 'V2_estado_civil-viuvo']
temp_data = full_df[cols].values.tolist()
full_df.drop(columns=cols, inplace=True)
col_data = get_categorical(temp_data, estado_civil)
full_df['V2_estado_civil'] = col_data
# transporte mais utilizado
print('Processing "transporte_mais_utilizado"...')
cols = ['V1_transporte_mais_utilizado-particular', 'V1_transporte_mais_utilizado-publico', 'V1_transporte_mais_utilizado-taxi']
temp_data = full_df[cols].values.tolist()
full_df.drop(columns=cols, inplace=True)
col_data = get_categorical(temp_data, transporte_mais_utilizado)
full_df['V1_transporte_mais_utilizado'] = col_data
cols = ['V2_transporte_mais_utilizado-particular', 'V2_transporte_mais_utilizado-publico', 'V2_transporte_mais_utilizado-taxi']
temp_data = full_df[cols].values.tolist()
full_df.drop(columns=cols, inplace=True)
col_data = get_categorical(temp_data, transporte_mais_utilizado)
full_df['V2_transporte_mais_utilizado'] = col_data
# grau
print('Processing "grau"...')
cols = ['grau-amigos', 'grau-familia', 'grau-trabalho']
temp_data = full_df[cols].values.tolist()
full_df.drop(columns=cols, inplace=True)
col_data = get_categorical(temp_data, grau)
full_df['grau'] = col_data
# proximidade
print('Processing "proximidade"...')
cols = ['proximidade-mora_junto', 'proximidade-visita_casual', 'proximidade-visita_frequente', 'proximidade-visita_rara']
temp_data = full_df[cols].values.tolist()
full_df.drop(columns=cols, inplace=True)
col_data = get_categorical(temp_data, proximidade)
full_df['proximidade'] = col_data
full_df.head()
def denormalize(df, col, norm_params):
maxim = norm_params[f'max_{col}']
minim = norm_params[f'min_{col}']
v_col = f'V1_{col}'
df[v_col] = (df[v_col]*(maxim - minim)) + minim
v_col = f'V2_{col}'
df[v_col] = (df[v_col]*(maxim - minim)) + minim
cols = ['idade', 'qt_filhos', 'IMC']
for col in tqdm(cols):
denormalize(full_df, col, norm_params)
full_df.to_csv(f'{DATA_FOLDER}conexoes.csv', index=False)
full_df = pd.read_csv(f'{DATA_FOLDER}conexoes.csv')
full_df.head()
Finalmente, vamos realizar algumas análises em busca de possíveis padrões.
import plotly.express as px
fig = px.histogram(full_df, x='prob_V1_V2')
fig.show()